Skip to content

Conversation

@KyleAMathews
Copy link
Collaborator

@KyleAMathews KyleAMathews commented Jan 20, 2026

Summary

Fixes offline transactions not restoring optimistic state when the page is refreshed while offline. Users now see their pending changes immediately on page load, providing a seamless offline UX.


Root Cause

When the OfflineExecutor loaded pending transactions from storage on startup, it only scheduled them for execution—it never re-applied the mutations to the collection's state manager. The optimistic state lived only in memory and was lost on page refresh.

Approach

Created "restoration transactions" that shadow the persisted offline transactions:

// TransactionExecutor.restoreOptimisticState()
const restorationTx = createTransaction({
  id: offlineTx.id,
  autoCommit: false,
  mutationFn: async () => {},
})
restorationTx.applyMutations(offlineTx.mutations)
mutation.collection._state.transactions.set(restorationTx.id, restorationTx)
mutation.collection._state.recomputeOptimisticState(true)

When pending transactions are loaded from storage, we:

  1. Create a restoration transaction with the same ID
  2. Apply the persisted mutations to it
  3. Register it with each affected collection's state manager
  4. Clean up when the real offline transaction completes or fails

Key Invariants

  1. Restoration transactions never commit - they exist only to hold mutations for optimistic display
  2. Same ID as offline transaction - ensures proper cleanup when the real transaction resolves
  3. Cleanup on completion - whether success or failure, restoration transactions are removed so sync provides authoritative data
  4. Key extraction during deserialization - mutations need their key populated for optimistic state to work

Non-goals

  • Re-executing mutations on restore - we only restore the visual state, not re-run the mutation logic
  • Handling conflicts - conflict resolution happens when the actual sync completes, not during restoration

Trade-offs

Alternative considered: Storing the optimistic state separately from the transaction data.
Why this approach: Reusing the existing transaction/mutation infrastructure is simpler and leverages the collection's built-in optimistic state management. No new storage schema or state reconciliation logic needed.


Verification

pnpm test:pr

The new test should restore optimistic state to collection on startup verifies the complete flow:

  1. Create transaction with optimistic mutation
  2. Dispose executor (simulate page refresh)
  3. Create new executor with same storage
  4. Assert optimistic state is visible before transaction completes

Files Changed

File Description
OfflineExecutor.ts Added restorationTransactions map, registerRestorationTransaction(), cleanupRestorationTransaction(), and waitForInit() helper
TransactionExecutor.ts Added restoreOptimisticState() that creates restoration transactions on load
TransactionSerializer.ts Extract key from modified data during deserialization
offline-e2e.test.ts New test covering the page refresh scenario
harness.ts Minor test harness updates

@changeset-bot
Copy link

changeset-bot bot commented Jan 20, 2026

🦋 Changeset detected

Latest commit: 1eda746

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@tanstack/offline-transactions Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link

pkg-pr-new bot commented Jan 20, 2026

More templates

@tanstack/angular-db

npm i https://pkg.pr.new/@tanstack/angular-db@1169

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@1169

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@1169

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@1169

@tanstack/offline-transactions

npm i https://pkg.pr.new/@tanstack/offline-transactions@1169

@tanstack/powersync-db-collection

npm i https://pkg.pr.new/@tanstack/powersync-db-collection@1169

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@1169

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@1169

@tanstack/rxdb-db-collection

npm i https://pkg.pr.new/@tanstack/rxdb-db-collection@1169

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@1169

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@1169

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@1169

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@1169

commit: 1eda746

…refresh

Add a failing test that demonstrates the issue where offline transactions
do not restore optimistic state to the collection when the page is
refreshed while offline. Users have to manually handle this in beforeRetry
by replaying all transactions into the collection.

The test asserts the expected behavior (optimistic data should be present
after page refresh) and currently fails, demonstrating the bug.
@KyleAMathews KyleAMathews force-pushed the claude/investigate-offline-transactions-bug-2FDgc branch from c618c2c to 72f94f9 Compare January 20, 2026 19:39
@github-actions
Copy link
Contributor

github-actions bot commented Jan 20, 2026

Size Change: 0 B

Total Size: 90.8 kB

ℹ️ View Unchanged
Filename Size
./packages/db/dist/esm/collection/change-events.js 1.39 kB
./packages/db/dist/esm/collection/changes.js 1.19 kB
./packages/db/dist/esm/collection/events.js 388 B
./packages/db/dist/esm/collection/index.js 3.32 kB
./packages/db/dist/esm/collection/indexes.js 1.1 kB
./packages/db/dist/esm/collection/lifecycle.js 1.68 kB
./packages/db/dist/esm/collection/mutations.js 2.34 kB
./packages/db/dist/esm/collection/state.js 3.49 kB
./packages/db/dist/esm/collection/subscription.js 3.62 kB
./packages/db/dist/esm/collection/sync.js 2.41 kB
./packages/db/dist/esm/deferred.js 207 B
./packages/db/dist/esm/errors.js 4.7 kB
./packages/db/dist/esm/event-emitter.js 748 B
./packages/db/dist/esm/index.js 2.69 kB
./packages/db/dist/esm/indexes/auto-index.js 742 B
./packages/db/dist/esm/indexes/base-index.js 766 B
./packages/db/dist/esm/indexes/btree-index.js 1.93 kB
./packages/db/dist/esm/indexes/lazy-index.js 1.1 kB
./packages/db/dist/esm/indexes/reverse-index.js 513 B
./packages/db/dist/esm/local-only.js 837 B
./packages/db/dist/esm/local-storage.js 2.1 kB
./packages/db/dist/esm/optimistic-action.js 359 B
./packages/db/dist/esm/paced-mutations.js 496 B
./packages/db/dist/esm/proxy.js 3.75 kB
./packages/db/dist/esm/query/builder/functions.js 733 B
./packages/db/dist/esm/query/builder/index.js 4.08 kB
./packages/db/dist/esm/query/builder/ref-proxy.js 1.05 kB
./packages/db/dist/esm/query/compiler/evaluators.js 1.42 kB
./packages/db/dist/esm/query/compiler/expressions.js 430 B
./packages/db/dist/esm/query/compiler/group-by.js 1.81 kB
./packages/db/dist/esm/query/compiler/index.js 1.96 kB
./packages/db/dist/esm/query/compiler/joins.js 2 kB
./packages/db/dist/esm/query/compiler/order-by.js 1.45 kB
./packages/db/dist/esm/query/compiler/select.js 1.06 kB
./packages/db/dist/esm/query/expression-helpers.js 1.43 kB
./packages/db/dist/esm/query/ir.js 673 B
./packages/db/dist/esm/query/live-query-collection.js 360 B
./packages/db/dist/esm/query/live/collection-config-builder.js 5.42 kB
./packages/db/dist/esm/query/live/collection-registry.js 264 B
./packages/db/dist/esm/query/live/collection-subscriber.js 1.93 kB
./packages/db/dist/esm/query/live/internal.js 145 B
./packages/db/dist/esm/query/optimizer.js 2.56 kB
./packages/db/dist/esm/query/predicate-utils.js 2.97 kB
./packages/db/dist/esm/query/subset-dedupe.js 921 B
./packages/db/dist/esm/scheduler.js 1.3 kB
./packages/db/dist/esm/SortedMap.js 1.3 kB
./packages/db/dist/esm/strategies/debounceStrategy.js 247 B
./packages/db/dist/esm/strategies/queueStrategy.js 428 B
./packages/db/dist/esm/strategies/throttleStrategy.js 246 B
./packages/db/dist/esm/transactions.js 2.9 kB
./packages/db/dist/esm/utils.js 924 B
./packages/db/dist/esm/utils/browser-polyfills.js 304 B
./packages/db/dist/esm/utils/btree.js 5.61 kB
./packages/db/dist/esm/utils/comparison.js 852 B
./packages/db/dist/esm/utils/cursor.js 457 B
./packages/db/dist/esm/utils/index-optimization.js 1.51 kB
./packages/db/dist/esm/utils/type-guards.js 157 B

compressed-size-action::db-package-size

@github-actions
Copy link
Contributor

github-actions bot commented Jan 20, 2026

Size Change: 0 B

Total Size: 3.7 kB

ℹ️ View Unchanged
Filename Size
./packages/react-db/dist/esm/index.js 225 B
./packages/react-db/dist/esm/useLiveInfiniteQuery.js 1.17 kB
./packages/react-db/dist/esm/useLiveQuery.js 1.34 kB
./packages/react-db/dist/esm/useLiveSuspenseQuery.js 559 B
./packages/react-db/dist/esm/usePacedMutations.js 401 B

compressed-size-action::react-db-package-size

claude and others added 2 commits January 20, 2026 20:12
When the page is refreshed while offline with pending transactions,
the optimistic state was not being restored to collections. Users had
to manually replay transactions in `beforeRetry` to restore UI state.

This fix:
1. In `loadPendingTransactions()`, creates restoration transactions that
   hold the deserialized mutations and registers them with the collection's
   state manager to display optimistic data immediately

2. Properly reconstructs the mutation `key` during deserialization using
   the collection's `getKeyFromItem()` method, which is needed for
   optimistic state lookup

3. Cleans up restoration transactions when the offline transaction
   completes or fails, allowing sync data to take over

4. Adds `waitForInit()` method to allow waiting for full initialization
   including pending transaction loading

5. Updates `loadAndReplayTransactions()` to not block on execution,
   so initialization completes as soon as optimistic state is restored
KyleAMathews and others added 4 commits January 20, 2026 13:24
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Consolidate restorationTransactions.delete() to single point
- Improve comments on restoration transaction purpose

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add try-catch isolation in restoreOptimisticState to prevent one bad
  transaction from breaking all restoration
- Add defensive null check for mutation.collection in cleanup methods
- Add test for rollback of restored optimistic state on permanent failure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
… cleanup

Add catch handler to restoration transaction's isPersisted promise to prevent
unhandled rejection when rollback() is called during cleanup. The rollback
calls reject(undefined) which would otherwise cause an unhandled rejection error.
@KyleAMathews KyleAMathews requested a review from kevin-dp January 21, 2026 14:04
@TomasGonzalez
Copy link

I reported this bug and just tested this PR build locally. After refreshing while offline, the optimistic state is now restored correctly and pending changes show up immediately on page load.
Looks good on my end ✅ Thanks for the quick fix!

@samwillis
Copy link
Collaborator

Review from GPT5.2:


Here’s my review of PR #1169 (“Restore offline transactions to optimistic store upon restart the app”). Overall: the direction is solid and the tests you added are the right shape. The core idea—rehydrating pending offline mutations into a “restoration transaction” so collections can rebuild optimistic state on startup—makes sense. ([GitHub]1)

What looks good

  • Repro + coverage: The new e2e repro captures the real UX failure mode (refresh while offline → UI loses optimistic state until sync eventually happens). ([GitHub]2)
  • Restoration mechanism: Creating an internal transaction (never committed) and registering it in each touched collection’s optimistic transaction map is a pragmatic way to “plug back into” the existing optimistic-state machinery. ([GitHub]1)
  • Cleanup & rollback paths: Tracking restoration transactions and explicitly removing/rolling them back on completion or permanent failure is the right lifecycle model, and the added rollback test is a strong guardrail. ([GitHub]1)
  • Defensive hardening: The try/catch isolation and null checks around potentially-corrupt deserialized mutations are sensible. ([GitHub]3)

Must-fix / likely bugs

1) executeAll() is called twice during startup

In OfflineExecutor.loadAndReplayTransactions() you await this.executor.executeAll() and then immediately call this.executor.executeAll().catch(...) again, while the comment says “don’t await”. That’s almost certainly unintended and risks double-scheduling / double-processing. ([GitHub]1)

Suggestion: pick one:

  • If you truly want non-blocking init: await loadPendingTransactions(); executor.executeAll().catch(...) (no awaited executeAll)
  • If you want blocking init: await loadPendingTransactions(); await executor.executeAll() and remove the second call + comment

(Your new waitForInit() is a nice hook either way.) ([GitHub]1)

2) TransactionSerializer.deserializePendingMutation() returns duplicate fields + likely wrong key for deletes

In the returned object literal, you currently set:

  • modified twice (modified: this.deserializeValue(...) and also modified), and
  • key twice (key: null and then key) — the earlier one is dead and confusing. ([GitHub]3)

More importantly: key is computed only from modified. For delete mutations, modified is usually null and original holds the record—so deletes may not restore optimistic state correctly after refresh.

Suggestion (safer + cleaner):

  • Deserialize once (modified, original)
  • Compute key from modified ?? original (or based on type, if you prefer)
  • Return each field exactly once

This is pretty load-bearing because restoration relies on key lookups to rebuild optimistic state. ([GitHub]3)

Nice-to-haves / design notes

  • Private _state poking: You’re reaching into collection._state.transactions and calling _state.recomputeOptimisticState(...). It works, but it’s a sharp edge. If you can introduce a tiny internal helper on Collection/StateManager (even non-public) like registerOptimisticTransaction(tx) / unregisterOptimisticTransaction(txId, opts) you’d centralize invariants and reduce “knowledge leakage.” ([GitHub]1)
  • waitForLeader() loop: In the test harness you call waitForInit() inside the polling loop. It’s fine, but you could simplify to: wait for leader → await waitForInit() once. (Current version is still correct if waitForInit resolves quickly.) ([GitHub]4)
  • Test naming/comment: The original repro test still reads like it’s expected to fail (“BUG: currently does not”). If it now passes in this PR, it’d be good to update the wording so it doesn’t confuse future readers. ([GitHub]2)

One question to sanity-check (not blocking, but important)

Do offline “real” transactions use the same transaction id as the persisted offline transaction id (the one you’re using for restorationTx.id)? It seems like you’re intentionally matching ids so the optimistic subsystem behaves consistently across refresh—which is good—but if there’s any path where a second live Transaction with the same id is created while the restoration tx is still registered, you could get subtle clashes. The cleanup logic helps, but it’s worth confirming the invariants. ([GitHub]1)

Copy link
Collaborator

@samwillis samwillis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is good to go.

GPT5 is wrong on the first issue it found, its misread the diff.

@KyleAMathews KyleAMathews merged commit cbd1bb2 into main Jan 23, 2026
7 checks passed
@KyleAMathews KyleAMathews deleted the claude/investigate-offline-transactions-bug-2FDgc branch January 23, 2026 20:41
@github-actions github-actions bot mentioned this pull request Jan 23, 2026
@github-actions
Copy link
Contributor

🎉 This PR has been released!

Thank you for your contribution!

@KyleAMathews KyleAMathews moved this from Ready for review to Done in TanStack DB 1.0.0 release Jan 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

5 participants